前幾天我們建好了 RAG、四個通用指標和四個自訂指標。今天是整個系列的收尾,要用這些指標做真正的工程工作:先找出系統哪裡不好、判斷問題出在檢索還是生成,再調整參數,最後用數字驗證改動到底有沒有用。
k、chunk_size),跑出三組實驗沒有目標的調參,很容易變成亂槍打鳥。正確的順序是:
跑 baseline → 錯誤分析(找出問題在哪)→ 提出假設 → 改一個參數 → 重新評估 → 比較
為了讓每次調整都能重複、可比較,新建 experiment.py,把一組設定(chunk_size、chunk_overlap、k)定義成一個「實驗」:
EXPERIMENTS = {
"baseline": {"chunk_size": 500, "chunk_overlap": 50, "k": 4},
"k6": {"chunk_size": 500, "chunk_overlap": 50, "k": 6},
"cs250": {"chunk_size": 250, "chunk_overlap": 25, "k": 4},
"cs1000": {"chunk_size": 1000, "chunk_overlap": 100, "k": 4},
}
每次只改一個變因:k6 只改 k,cs250 與 cs1000 只改 chunk_size。如果同時改兩個,分數變了你就不知道是哪個造成的。
每個實驗會建立自己專屬的向量庫(collection 名稱為 exp-實驗名稱),這樣不同的切割方式不會互相覆蓋:
def get_experiment_store(name, cfg):
store = Chroma(
collection_name=f"exp-{name}",
embedding_function=get_embeddings(),
persist_directory=PERSIST_DIR,
collection_configuration={"hnsw": {"space": "cosine"}},
)
if not store.get()["ids"]: # 還沒建過才建庫
chunks = split_documents(load_documents(), cfg["chunk_size"], cfg["chunk_overlap"])
store.add_documents(chunks)
return store
這個函式有快取:已經建過的實驗庫不會重建。但這也意味著如果你修改了某個實驗的參數,必須改實驗名稱,否則會讀到舊參數建的庫。
以我們的 5 份範例文件為例,三種切割大小產生的 chunk 數量分別是:
chunk_size |
250 | 500 | 1000 |
|---|---|---|---|
| chunk 總數 | 28 | 13 | 5 |
注意 chunk_size=1000 時全庫只剩 5 個 chunks,而 k=4,等於每次幾乎把整個知識庫都撈回來。這會對檢索精確度造成什麼影響,是今天實驗的一個看點。
LLM 評審的分數有雜訊,但檢索有一個完全確定、不會有雜訊的檢查方式:測試集每題都標了 source(答案應該出自哪份文件),我們直接看撈回來的 chunks 有沒有來自那份文件:
def run_chain(chain, testset):
rows = []
for i, item in enumerate(testset, 1):
start = time.perf_counter()
result = chain.invoke(item["question"])
latency = time.perf_counter() - start
sources = [os.path.basename(d.metadata["source"]) for d in result["context"]]
rows.append({
**item,
"answer": result["answer"],
"contexts": [d.page_content for d in result["context"]],
"retrieved_sources": sources,
"source_hit": item["source"] in sources,
"latency": round(latency, 2),
})
print(f"[{i}/{len(testset)}] {latency:5.1f}s | 命中={rows[-1]['source_hit']} | {item['question']}")
return rows
source_hit 為 True 代表至少有一個 chunk 來自正確的文件。它跟昨天的「規則型指標」是同一個精神:能用規則判斷的,就不要交給評審模型。當你要確認「調整檢索參數有沒有讓檢索變好」,這個數字比 Context Recall 可靠得多。
為什麼 baseline 要重跑,而不直接用 DAY27 的結果?兩個理由:DAY27 的輸出沒有記錄 retrieved_sources,無法判斷命中;而且重跑之後,我們還能拿它跟 DAY27 的分數比較,量出評審模型本身的雜訊有多大,後面會用到。
執行:
uv run python experiment.py run baseline
這會跑 20 題生成、5 題知識庫外問題,以及完整的 RAGAS 評分,耗時與 DAY27 相當,請預留時間。跑完會產生 eval/exp_baseline.csv 與 eval/experiments.csv。
接著執行錯誤分析:
uv run python experiment.py analyze baseline
診斷邏輯寫在 diagnose() 裡,依序判斷:
RECALL_LOW = 0.5
FAITHFULNESS_LOW = 0.7
RELEVANCY_LOW = 0.5
def diagnose(row):
if not row["source_hit"]:
return "檢索失敗:沒撈到正確文件"
if pd.notna(row["context_recall"]) and row["context_recall"] < RECALL_LOW:
return "檢索不足:撈到文件但缺關鍵內容"
if pd.notna(row["faithfulness"]) and row["faithfulness"] < FAITHFULNESS_LOW:
return "生成問題:答案超出參考資料"
if pd.notna(row["answer_relevancy"]) and row["answer_relevancy"] < RELEVANCY_LOW:
return "生成問題:答非所問"
if row[METRIC_NAMES].isna().any():
return "無法判斷:有指標評分失敗(NaN)"
return "正常"
判斷順序是刻意的:先檢索、後生成。 檢索都失敗了,後面的生成分數沒有討論意義。這些門檻是經驗法則,請依你自己 baseline 的分數分布調整。
分析會輸出三樣東西:
請務必親眼看過清單裡的每一題。 自動診斷只是幫你縮小範圍,真正的原因要看內容才知道:是 chunk 把關鍵句切斷了?是兩份文件主題相近而混淆了?還是標準答案寫得有問題?有時候低分的原因出在測試集,而不是系統。
看完錯誤分析,你會有一些假設。下面是常見的對應,請依你實際看到的分類挑選:
| 你看到的現象 | 假設 | 對應的實驗 |
|---|---|---|
| 「檢索不足」居多,尤其是比較題 | 比較題需要撈到兩邊的資訊,k 太小 |
k6 |
| 「檢索失敗」居多,撈到的 chunks 很零碎 | chunk 太小,語意破碎 | cs1000 |
| Context Precision 偏低,撈到很多雜訊 | chunk 太大,一段混了多個主題 | cs250 |
| 「生成問題」居多 | 檢索沒問題,應該改 prompt,而不是調這三個參數 | 不跑這些實驗 |
最後一列很重要:如果診斷顯示主要是生成問題,今天這三個實驗就幫不上忙,該回頭改 DAY26 的 prompt。不要因為實驗已經寫好了就硬跑。
執行你想驗證的實驗(可以挑一到三個):
uv run python experiment.py run k6
uv run python experiment.py run cs250
uv run python experiment.py run cs1000
每個實驗都是完整的一輪生成與評分,建議分開跑,甚至放著讓它在背景跑。
uv run python experiment.py compare
這會輸出三張表:
1. 實驗比較表:每個實驗的設定、四個 RAGAS 指標、檢索命中率、三個自訂規則型指標(繁體中文合規率、誤拒率、正確拒答率)、平均回應時間與 NaN 總數。
2. 與 baseline 的差距:每個指標比 baseline 高多少或低多少。
3. 雜訊參考:DAY27 的 baseline 與今天重跑的 baseline,在四個指標上差多少。